Skip to content

fix(core): a decoded buffer's handover leaves the routed element silent - #7309

Merged
miguel-heygen merged 2 commits into
mainfrom
rsafari/echo-after-handover
Nov 6, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
rsafari/echo-after-handover

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

What

When a decoded buffer takes over a clip's sound in WebKit, the clip's media element is released without being left sounding: releaseRouted now disposes the routed source without reconnecting it to the audio destination.

Why

On a long source in Safari, the first seconds of some takes sounded twice: one copy on time, a second 200 to 320 ms later. The element's own late restart (WebKit starts a media element's audio 150 to 400 ms late) kept sounding through the destination after the buffer had taken over, because releasing a routed element reconnected it there ("the element then sounds natively") and WebKit does not silence a captured element for muted.

Related work

Follows the buffer handover that moved the start of the sound onto the context clock. Refs the Safari audio and picture offset reports.

How

releaseRouted calls dispose(routed, false): while the buffer plays, the captured element has no output, which is silent, and the buffer carries the sound. stopAll(), which every pause and seek goes through, then reconnects each handed-over element's cached source node to the destination, so Studio's scrub audition (the element played directly under a paused clock) is audible, as the rule that a captured element is connected whenever no play runs requires. That holds whether the buffer was still playing or had already ended on its own. A later rate change reschedules the element through scheduleMediaElementPlayback, which reuses the one source node per element and context.

Test plan

  • Unit tests added/updated in webAudioTransport.test.ts: keeps a routed element silent once a decoded buffer plays in its place and sounds a handed-over element through the output again once playback stops. fails without the fix: on main the first fails at expect(node.connect).not.toHaveBeenCalled() (one call, to the destination); on the previous head of this PR the second fails with the node never connected to the destination. With both changes webAudioTransport, webAudioTransportIdle, init and media tests pass, 605 tests.
  • Manual testing performed: output capture of a 321 s click-encoded fixture (a click and a magenta frame start together; ScreenCaptureKit buffers with presentation timestamps), iOS 26.5 Simulator on a desktop Mac, one run each. Cues that sounded twice, of 50: stock 0.9.19 10 (64 click onsets), with this change 1 (55 onsets). Earlier stock Simulator runs showed 17 of 50 and, in desktop Safari, 4 to 8 of 50. The Mac was busy and each build has one run, so the exact count is rough; the drop is not.
  • Not exercised: a real iPhone, a rate change after a handover, a clip whose decode never completes.
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": one short comment, saying what the code must do.

Size

About 19 changed lines, one fix and its test: a lone fix that no open PR can carry (the only related PR is merged).

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f865165. Requesting changes. There are two blockers.

1. Scrub audition goes silent again in Safari after a handover (regresses #4517).
#4517 set a rule: a captured element is connected straight to the destination whenever no play is running, so Studio's scrub audition, which plays the element directly under a paused clock, is audible. stopAll() keeps that rule by calling dispose(source) with keepsSounding = true on media-element sources.

After this change, a handed-over element breaks the rule:

  • releaseRouted removes the element's media-element source from _activeSources and leaves its node disconnected.
  • On Pause or seek, stopAll() only sees the buffer source. dispose stops the buffer and puts back el.muted, but it never reconnects _mediaElementSources.get(el).
  • The element is now unmuted, captured and connected to nothing. The next scrub audition plays into silence, which is exactly the -180 dB case in #4517's table.

decodesFirst covers every non-looping WebKit <audio> element at rate 1 that fits the decode budget, which is the music-track case Studio auditions. So this hits Safari Studio users, not an edge case. The PR body's "rate change reschedules" path covers only the next play, not the paused state.

Suggested fix: keep the element disconnected while the buffer plays (which is the right fix for the double sound). When the buffer source that replaced it is disposed (stop or pause), reconnect the element's cached source node to the destination. One way is in dispose for a buffer source whose element is in _handedOver: connect its _mediaElementSources node to the destination when keepsSounding is true. Please add a test along the lines of: schedule → releaseRouted → stopAll() → expect the element's node to be connected to ctx.destination.

2. The Comments workflow fails. webAudioTransport.ts's comment share rises from 233 to 234 lines because the releaseRouted doc comment grew from one line to two. Get it back to one line (or fewer).

The rest checks out. The diagnosis of the double sound is sound, the fails without the fix: line names a real test that fails on main, and leaving the element disconnected during the buffer's play is the right change.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 55badf2. Approving.

Blocker 1 (scrub audition after a handover) is resolved. stopAll() now reconnects every handed-over element's cached source node to the destination and clears the set. That puts back #4517's rule that, outside a play, an element sounds through the destination. It covers both a buffer that was still playing and one that had already ended, since the reconnect is driven by _handedOver, not by _activeSources.

  • The new test sounds a handed-over element through the output again once playback stops checks exactly this.
  • On the next Play, scheduleMediaElementPlayback disconnects the node before it builds the chain, and a second handover's dispose(…, false) leaves it disconnected. So the echo fix holds and there's never a second path out.

Blocker 2 (comments) is resolved. The releaseRouted doc comment is back to one line, so the file's comment count matches its base. The Comments run on this head was still in progress when I reviewed.

Not blocking: _handedOver changed from a WeakSet to a Set so it can be iterated, which means it now holds strong references until the next stopAll(). An element swapped out with its scene therefore stays referenced until that stop, and is then connected to the destination. It's already paused by releaseDetachedMedia, so nothing is heard. Consider having releaseDetached() also delete detached elements from _handedOver, to match its "without sending it to the destination" contract.

CI, Safari audio and the other workflows were still running when I reviewed. This approval doesn't stand in for them.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 125 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (2)

  • seqplainkeys-none-pct-r0-nested-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true
  • seqfastkeys-none-px-r0-root-z100: tracking 0.07, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0.05, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit cb8e02e Nov 6, 2026
97 of 98 checks passed
@miguel-heygen
miguel-heygen deleted the rsafari/echo-after-handover branch November 6, 2026 10:36
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 6, 2026
…view to heygen-com#7250

Adopts heygen-com#7310 (MPEG-4 Part 2 / MJPEG get browser-playable copies), heygen-com#7316
(check_report counts the copies a check waited for) and heygen-com#7317 (render reads
a composition src like the preview and refuses paths outside the project).
Defers heygen-com#7309 (needs heygen-com#6912); heygen-com#7314 does not apply. PR axis advances to heygen-com#7250
(heygen-com#7243 closed unmerged, heygen-com#7249 open and pinned as monitored; monitored 96->97).

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant